Skip to content

fix(contracts): enforce CEI in withdraw and cancel_stream to close reentrancy surface#947

Merged
ogazboiz merged 1 commit into
LabsCrypt:mainfrom
patopatrish:fix/789-cei-reentrancy-withdraw-cancel
Jul 3, 2026
Merged

fix(contracts): enforce CEI in withdraw and cancel_stream to close reentrancy surface#947
ogazboiz merged 1 commit into
LabsCrypt:mainfrom
patopatrish:fix/789-cei-reentrancy-withdraw-cancel

Conversation

@patopatrish

Copy link
Copy Markdown
Contributor

Closes #789

Problem

withdraw and cancel_stream both transferred tokens before committing updated stream state to storage, violating the Checks-Effects-Interactions (CEI) pattern.

token_address is a caller-supplied contract. A token with a transfer hook could re-enter withdraw or cancel_stream while persistent storage still reflected the pre-update state (stale withdrawn_amount, is_active = true), enabling a double payout.

Affected code paths

Function Bug
transfer_and_update_stream (called by withdraw) token_client.transfer fired before updating withdrawn_amount / is_active, and save_stream ran even later, after the helper returned
cancel_stream Both recipient and sender token_client.transfer calls fired before save_stream

Fix

apply_withdrawal (replaces transfer_and_update_stream) — new helper that enforces CEI:

  1. Effects: increment withdrawn_amount, set last_update_time, flip is_active/status if fully drained
  2. Persist: save_stream — state is now durable before any external call
  3. Interaction: token_client.transfer

withdraw — delegates to apply_withdrawal; the standalone save_stream call is removed (now inside the helper).

cancel_stream — restructured so all state mutations + save_stream precede both token transfers (recipient payout and sender refund).

Regression tests added

  • test_withdraw_state_committed_before_transfer_prevents_double_payout — after a successful withdraw, a second call at the same ledger timestamp (simulating a re-entrant hook) returns InvalidAmount because committed state already reflects the withdrawal.
  • test_cancel_state_committed_before_transfers_prevents_double_cancel — after cancel_stream, a second cancel attempt returns StreamInactive because the stream was marked inactive before either transfer fired.

Files changed

  • contracts/stream_contract/src/lib.rs
  • contracts/stream_contract/src/test.rs

@ogazboiz

ogazboiz commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

heads up: main's ci was broken (the Backend CI and Backend Docker Image CI jobs) until the fixes in #969 and #974 just landed, so the red backend/docker checks on this pr are almost certainly stale, they ran against the broken main. please rebase to re-test against the now-green main: git fetch origin && git rebase origin/main && git push --force-with-lease. once it's green i'll review and merge. (if a non-backend check like Frontend CI is still red after the rebase, that part is a real issue worth a look, since frontend ci was passing on main.)

…e reentrancy surface (LabsCrypt#789)

Both `withdraw` and `cancel_stream` previously executed token transfers before
persisting stream state, violating Checks-Effects-Interactions. A token contract
with a transfer hook could re-enter either function while storage still held the
pre-update state, enabling a double payout.

Changes:
- Rename `transfer_and_update_stream` → `apply_withdrawal`, which now follows
  CEI: update stream fields → save_stream (effects committed) → token transfer.
- `withdraw` delegates to `apply_withdrawal`; the separate `save_stream` call is
  removed since persistence is now handled inside the helper.
- `cancel_stream` is restructured so all state mutations and `save_stream` happen
  before either token transfer (recipient payout + sender refund).
- Two regression tests added: one for `withdraw` and one for `cancel_stream`,
  each asserting that a second call at the same timestamp fails because committed
  state already reflects the first operation.

Closes LabsCrypt#789

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@patopatrish patopatrish force-pushed the fix/789-cei-reentrancy-withdraw-cancel branch from 0222915 to ae7204b Compare July 2, 2026 04:33
@patopatrish

Copy link
Copy Markdown
Contributor Author

Done — rebased onto the latest upstream/main (which includes the fixes from #969 and #974) and force-pushed with --force-with-lease. CI should now run against the green main. We'll keep an eye on the checks and flag anything unexpected.

@ogazboiz ogazboiz left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

correct CEI refactor. apply_withdrawal now does the effects (withdrawn_amount +=, status/active updates) then save_stream then the token transfer, so state is committed before the external call, closing the reentrancy/double-payout window, and the redundant second save_stream in withdraw is gone. cancel does the same: updates withdrawn_amount + refunded_amount (derived from the updated withdrawn) + status, saves, then both transfers, so the invariant recipient+sender == deposited holds and the two regression tests prove it. cargo test green (99). merging.

@ogazboiz ogazboiz merged commit d1d25e4 into LabsCrypt:main Jul 3, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Contracts] withdraw/cancel transfer tokens before persisting stream state (CEI violation / reentrancy surface)

2 participants